查看原文
其他

埋点还可以这么做?这也太简单了

作者:慢功夫

https://juejin.cn/post/7238431954126929981

前言

在项目开发中通常会有埋点的需求,然而当项目过于庞大,给每个函数添加埋点函数是不现实的,这是其一。其二,埋点和业务逻辑没有关系,混入代码中会导致维护混乱🤪。
基于此,我们可以将埋点的任务用工具来做,而不是手动。这个工具就是babel
下面我们来看一个小Demo,看看babel埋点是如何实现的

这篇文章适用于了解babel插件开发基础的童鞋,同时想要了解用bable埋点的基本思路的童鞋


一个埋点的Demo

安装依赖

新建一个文件夹babel-tracker, 然后在这个文件夹内按照必要的依赖

mrdir babel-tracker
cd ./babel-tracker
npm init -y
npm i -D @babel/core @babel/helper-plugin-utils


添加测试代码

创建一个测试代码src/sourceCode.js,这个代码是用来测试添加埋点函数

//sourceCode.js

import "./index.css";

//##箭头函数
const test1 = () => {};

代码中有函数表达式,箭头函数,函数声明,类方法四种函数,待会就要在这四种方法里面分别插入埋点函数,就像下面这样

import _tracker from "tracker";
import "./index.css";

//##箭头函数
const test1 = () => {
  _tracker();
};


编写入口文件

然后新建一个文件src/index.js

const { transformFileSync } = require("@babel/core");
const path = require("path");

const pathFile = path.resolve(__dirname, "./sourceCode.js");

//transform ast and generate code
const { code } = transformFileSync(pathFile, {
 plugins: [
    //plugins
 ],
});

console.log(code);

这个文件做了三件事:

  1. 获取测试代码的文件路径
  2. 将测试代码用tracker 插件处理
  3. 将处理后的code打印出来

其中的第二步,是先将代码转成AST语法树,然后用插件对AST对象树做一系列的处理,最后将处理好的AST转回js代码。

我们所有的重点就是在用插件处理AST上面。
下面来创建一个插件src/babel-plugin-tracker-2.js

编写插件

基本思路是,先识别出这是一个函数,然后将在函数体内部添加一个表达式_tracker()

// 导出一个 Babel 插件的函数。它接受两个参数:
// `api` 是一个 Babel 插件 API 对象,提供了一些可以在插件中使用的方法。
// `options` 是用户在 Babel 配置文件中给该插件指定的选项。
module.exports = (api, options) => {
  // 返回一个插件对象。
  return {
    // `visitor` 对象定义了我们要访问的 AST 节点类型以及对应的处理方法。
    visitor: {
      // 对于 `ArrowFunctionExpression` 类型的节点(箭头函数表达式):
      ArrowFunctionExpression: {
        // 当我们进入一个节点时:
        enter(path, state) => {
          // `path` 是当前节点(箭头函数表达式)的路径对象,它提供了一些操作当前节点的方法。

          // 获取箭头函数的函数体的路径。
          const bodyPath = path.get("body");

          // 使用 Babel 插件 API 的 `template.statement` 方法创建一个新的 AST 节点,
          // 这个节点表示 `_tracker()` 这个语句。注意我们需要调用返回的函数(`()`)以生成 AST。
          const ast = api.template.statement('_tracker()')();

          // 将新生成的 `_tracker()` 调用语句插入到箭头函数的函数体的开头。
          bodyPath.node.body.unshift(ast);
        },
      },
    },
  };
};

我们将插件导入src/index.js文件中

//index.js

const { transformFileSync } = require("@babel/core");
const path = require("path");
const tracker = require("./babel-plugin-tracker"); //update
const pathFile = path.resolve(__dirname, "./sourceCode.js");

//transform ast and generate code
const { code } = transformFileSync(pathFile, {
 plugins: [[tracker]], //update
});

console.log(code);


运行Demo

好了,将写好的插件导入之后,就可以运行代码看看效果了

node ./src/index.js
image.png

运行成功

可以看到埋点的函数已经被放进去了。可以有个小问题,这个文件运行起来可能会报错,因为没有_tracker函数的import,需要先import才不会报错。

接下来我们来处理这个问题

处理_tracker的import

一般在bable中处理important是在Program的AST节点中处理的,所以需要在插件中处理Program节点.。
基本思路是,判断文件中是否有_tracker的import,如果没有,就添加一个导入

// 导入 `@babel/helper-module-imports` 包的 `addDefault` 函数
// 它可以向程序中添加默认导入
const { addDefault } = require("@babel/helper-module-imports");

// 导出一个 Babel 插件的函数。
module.exports = (api, options) => {
  return {
    visitor: {
      ArrowFunctionExpression: {
        enter(path, state) => {
          //...
        },
      },

      // 对于 `Program` 类型的节点(整个程序):
      Program: {
        // 当我们进入一个节点时:
        enter(path, state) => {
          // 从插件选项中获取 `_tracker` 函数的导入路径。
          const trackerPath = options.trackerPath;

          // 声明一个标志,初始值为 false,表示我们假设程序中没有导入 `_tracker`。
          let isHasTracker = false;

          // 遍历当前节点(整个程序)的所有子节点。
          path.traverse({
            // 对于 `ImportDeclaration` 类型的节点(导入声明):
            ImportDeclaration(path) {
              // 如果当前导入声明的来源与 `_tracker` 函数的导入路径相同:
              if (path.node.source.value === trackerPath) {
                // 将标志设置为 true,表示我们找到了 `_tracker` 的导入。
                isHasTracker = true;

                // 停止遍历,因为我们已经找到了 `_tracker` 的导入。
                path.stop();
              }
            },
          });

          // 如果我们遍历完所有导入声明后都没有找到 `_tracker` 的导入:
          if (isHasTracker === false) {
            // 使用 `addDefault` 函数向程序中添加 `_tracker` 函数的默认导入。
            // `options.trackerPath` 是 `_tracker` 函数的导入路径,
            // `{ nameHint: "_tracker" }` 是一个选项对象,用于指定导入的变量名。
            addDefault(path, options.trackerPath, { nameHint"_tracker" });
          }
        },
      },
    },
  };
};

添加了一个Program的处理函数,在逻辑中,遍历的了整个文件的import语句,并且一一比较了import的source,如果其中的source.value_tracker,说明文件已经导入了_tracker

一个import语句,如:import a from 'a.js' 那么可以通过node.source.value,获取这个AST节点中的a.js

在判断_tracker的导入路径的时候,代码中是从options.trackerPath中获取的,而options的配置在插件的引用的地方。并没有hard code

//transform ast and generate code
const { code } = transformFileSync(pathFile, {
 plugins: [[tracker,{ trackerPath'tracker'}]], //update
});

如果没有发现tracker的导入,就需要手动添加了。代码中借用的是addDefault的依赖帮忙添加的。其中{ nameHint: "_tracker" }用来设置_tracker作为埋点函数的变量名。
我们来跑下代码:

node ./src/index.js
image.png

添加成功


看起来大功告成了。我们来捋一下过程:

  1. 遍历函数,在函数中添加埋点函数
  2. 查找是否有tracker的导入,如果没有,就手动添加

过程很简单,但过于简陋,有几处可以改进的地方:

  1. 不仅给箭头函数添加,还可以给函数表达式,函数声明,类方法等函数形式添加埋点
  2. 添加tracker的导入,埋点函数变量名_tracker可能会被使用过,所以最好是随机生成埋点函数的变量名
  3. 如果文件中已经导入了tracker,我们需要获取用户定义的变量名,并且使用该变量名给函数添加埋点。例如import _tracker2 from 'tracker'; ,这时候调用埋点就要变成 _tracker2();

改进


给其他的函数类型添加埋点

visitor: {
  "ArrowFunctionExpression|FunctionDeclaration|ClassMethod|FunctionExpression": {
      // 当我们进入一个节点时:
      enter(path, state) => {
        // `path` 是当前节点(箭头函数表达式)的路径对象,它提供了一些操作当前节点的方法。
  
        // 获取箭头函数的函数体的路径。
        const bodyPath = path.get("body");
  
        // 使用 Babel 插件 API 的 `template.statement` 方法创建一个新的 AST 节点,
        // 这个节点表示 `_tracker()` 这个语句。注意我们需要调用返回的函数(`()`)以生成 AST。
        const ast = api.template.statement('_tracker()')();
  
        // 将新生成的 `_tracker()` 调用语句插入到箭头函数的函数体的开头。
        bodyPath.node.body.unshift(ast);
      },
    },
},

babel提供这样的功能,字符串拼接的方法来表示遍历多种类型的AST,这样就完成了多种函数类型都可以差入埋点函数了
我们修改测试代码,并且运行看看

import "./index.css";

//##箭头函数
const test1 = () => {};

//函数表达式
const test2 = function () {};

// 函数声明
function test3() {}

// 类方法
class test4 {
    test4_0() {}

    test4_1 = () => {};

    test4_2 = function () {};
}
node ./src/index.js
image.png

每个函数都有埋点

不过有一个点,如果箭头函数直接返回结果,现有的代码是不支持的,形如const test_5 = ()=>0,函数体只是一个statement,而不是一个数组,所以强行执行unshift操作会报错。

需要对代码做些修改

visitor: {
  "ArrowFunctionExpression|FunctionDeclaration|ClassMethod|FunctionExpression": {
    // 当我们进入一个节点时:
    enter(path, state) => {
      // `path` 是当前节点(箭头函数表达式)的路径对象,它提供了一些操作当前节点的方法。

      // 获取箭头函数的函数体的路径。
      const bodyPath = path.get("body");

      // 使用 Babel 插件 API 的 `template.statement` 方法创建一个新的 AST 节点,
      // 这个节点表示 `_tracker()` 这个语句。注意我们需要调用返回的函数(`()`)以生成 AST。
      const ast = api.template.statement('_tracker()')();
      if (bodyPath.isBlockStatement()) {
        bodyPath.node.body.unshift(ast);
      } else {
        const ast2 = api.template.statement(`{
            _tracker();
            return BODY;
        }`
)({ BODY: bodyPath.node });

        bodyPath.replaceWith(ast2);
      }
    }
  }
}

在代码中,做了一个对函数节点body属性值类型的判断,如果是isBlockStatement,那就可以执行unshift,如果不是,说明函数单纯返回了一个值,这时候就需要将函数体变成blockStatement,并且函数的返回值依然是原来的值。形如const test_5 = ()=>0变成const test_5 = ()=>{ return 0; }。这样就可以添加埋点函数了
运行看看:

搞定


处理埋点函数变量名

visitor: {
  "ArrowFunctionExpression|FunctionDeclaration|ClassMethod|FunctionExpression": {
    enter(path, state) => {
      const types = api.types;
      const bodyPath = path.get("body");
      const ast = state.trackerAst;
      if (types.isBlockStatement(bodyPath.node)) {
        bodyPath.node.body.unshift(ast);
      } else {
        const ast2 = api.template.statement(`{
              ${state.importTrackerId}();
              return BODY;
            }`
)({ BODY: bodyPath.node });

        bodyPath.replaceWith(ast2);
      }
    },
  },

  Program: {
    enter(path, state) => {
      const trackerPath = options.trackerPath;
      path.traverse({
        ImportDeclaration(path) {
          if (path.node.source.value === trackerPath) {
            const specifiers = path.get("specifiers.0");
            state.importTrackerId = specifiers.get("local").toString();
            path.stop();
          }
        },
      });

      if (!state.importTrackerId) {
        state.importTrackerId = addDefault(path, options.trackerPath, {
          nameHint: path.scope.generateUid("tracker"),
        }).name;
      }

      state.trackerAst = api.template.statement(`${state.importTrackerId}();`)();
    },
  },
},
  1. 使用了path.scope.generateUid("tracker")来生成当前作用域内唯一的变量。
  2. 借助state,来传递生成的变量,或者是已经定义的变量
  3. 在插入埋点函数的时候,就可以读取state中的变量了


总结:

这篇文章较为基础,讲了如何在函数中添加埋点函数,以及如何处理埋点函数的import。
在埋点的时候,需要注意一下几个问题:

  1. 函数形态的多样性
  2. 埋点函数的变量是否已经定义,如果已经定义,插入埋点的时候,就要使用已经定义的变量名;如果没有定义,插入import的时候,就要保证插全局变量名的唯一性

对每个函数都执行插入埋点操作还是有问题,实际情况并不需要这么做。下篇文章讲讲如何根据注释来添加埋点。


- EOF -

推荐阅读  点击标题可跳转

1、你只会用前端数据埋点 SDK 吗?

2、大厂前端日常窥探:前端大佬们都在干啥?

3、虚拟滚动的 3 种实现方式~学完直接写简历亮点上!


觉得本文对你有帮助?请分享给更多人

关注「大前端技术之路」加星标,提升前端技能

点赞和在看就是最大的支持❤️

继续滑动看下一个
大前端技术之路
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存